import 'dart:convert';
import 'dart:typed_data';

import 'package:archive/archive.dart';
import 'package:dbio_utils/generic_filehandle/generic_file_handle.dart';
import 'package:dbio_utils/tabix/chunk.dart';

import 'dart:math' show pow;
import 'virtual_offset.dart';
import 'util.dart';
import 'index_file.dart';

const TBI_MAGIC = 21578324; // TBI\1
const TAD_LIDX_SHIFT = 14;

reg2bins(int beg, int end) {
  beg += 1; // < convert to 1-based closed
  end -= 1;
  var list = [0];
  for (var k = 1 + (beg >> 26); k <= 1 + (end >> 26); k += 1) list.add(k);
  for (var k = 9 + (beg >> 23); k <= 9 + (end >> 23); k += 1) list.add(k);
  for (var k = 73 + (beg >> 20); k <= 73 + (end >> 20); k += 1) list.add(k);
  for (var k = 585 + (beg >> 17); k <= 585 + (end >> 17); k += 1) list.add(k);
  for (var k = 4681 + (beg >> 14); k <= 4681 + (end >> 14); k += 1) list.add(k);
  return list;
}

class TabixIndex extends IndexFile {
  TabixIndex({
    GenericFileHandle fileHandle,
    Function renameRefSeq,
  }) : super(
          fileHandle: fileHandle,
          renameRefSeq: renameRefSeq,
        );

  @override
  Future<List<Chunk>> blocksForRange({String refName, int start, int end, Map<dynamic, dynamic> opts}) async {
    if (start < 0) start = 0;

    var indexData = await this.parse(opts);
    if (indexData == null) return [];
    var refId = indexData.refNameToId[refName];
    if (refId == null) return [];
    var indexes = indexData.indices[refId];
    if (indexes == null) return [];

    List linearIndex = indexes['linearIndex'];
    var binIndex = indexes['binIndex'];

    List bins = reg2bins(start, end);

    VirtualOffset minOffset = linearIndex.length > 0 ? linearIndex[start >> TAD_LIDX_SHIFT >= linearIndex.length ? linearIndex.length - 1 : start >> TAD_LIDX_SHIFT] : VirtualOffset(0, 0);
    if (minOffset == null) {
      print('querying outside of possible tabix range');
      return [];
    }

    var l;
    var numOffsets = 0;
    for (var i = 0; i < bins.length; i += 1) {
      if (binIndex[bins[i]] != null) numOffsets += binIndex[bins[i]].length;
    }

    if (numOffsets == 0) return [];

    List<Chunk> off = [];
    numOffsets = 0;
    for (var i = 0; i < bins.length; i += 1) {
      List<Chunk> chunks = binIndex[bins[i]];
      if (chunks != null)
        for (var j = 0; j < chunks.length; j += 1)
          if (minOffset.compareTo(chunks[j].maxv) < 0) {
            off.add(Chunk(
              chunks[j].minv,
              chunks[j].maxv,
              chunks[j].bin,
            ));
            numOffsets += 1;
          }
    }

    if (off.length <= 0) return [];

    off.sort((a, b) => a.compareTo(b));

    // resolve completely contained adjacent blocks
    l = 0;
    for (var i = 1; i < numOffsets; i += 1) {
      if (off[l].maxv.compareTo(off[i].maxv) < 0) {
        l += 1;
        off[l].minv = off[i].minv;
        off[l].maxv = off[i].maxv;
      }
    }
    numOffsets = l + 1;

    // resolve overlaps between adjacent blocks; this may happen due to the merge in indexing
    for (var i = 1; i < numOffsets; i += 1) {
      if (off[i - 1].maxv.compareTo(off[i].minv) >= 0) {
        off[i - 1].maxv = off[i].minv;
      }
    }

    // merge adjacent blocks
    l = 0;
    for (var i = 1; i < numOffsets; i += 1) {
      if (off[l].maxv.blockPosition == off[i].minv.blockPosition)
        off[l].maxv = off[i].maxv;
      else {
        l += 1;
        off[l].minv = off[i].minv;
        off[l].maxv = off[i].maxv;
      }
    }
    numOffsets = l + 1;

    return off.sublist(0, numOffsets);
  }

  @override
  Future<int> lineCount(String refName, Map<dynamic, dynamic> options) async {
    var indexData = await this.parse(options);
    if (indexData == null) return -1;
    var refId = indexData.refNameToId[refName];
    if (refId == null) return -1;
    var idx = indexData.indices[refId];
    if (idx == null) return -1;
    var stats = idx['stats'];
    if (stats != null) return stats['lineCount'];
    return -1;
  }

  @override
  Future<TbiBean> parseImpl(Map<dynamic, dynamic> option) async {
    Uint8List __bytes = await this.fileHandle.readFile();
    List<int> _bytes = GZipDecoder().decodeBytes(__bytes);

    ByteData bytes = ByteData.view(Uint8List.fromList(_bytes).buffer);
    checkAbortSignal(option['signal']);

    // check TBI magic numbers
    if (bytes.getInt32(0, Endian.little) != TBI_MAGIC /* "TBI\1" */) {
      throw Exception('Not a TBI file');
      // TODO: do we need to support big-endian TBI files?
    }

    // number of reference sequences in the index
    var refCount = bytes.getInt32(4, Endian.little);
    var formatFlags = bytes.getInt32(8, Endian.little);
    var coordinateType = formatFlags & 0x10000 != 0 ? 'zero-based-half-open' : '1-based-closed';
    Map formatOpts = {
      0: 'generic',
      1: 'SAM',
      2: 'VCF',
    };
    var format = formatOpts[formatFlags & 0xf];
    if (format == null) throw Exception('invalid Tabix preset format flags $formatFlags');
    var columnNumbers = {
      'ref': bytes.getInt32(12, Endian.little),
      'start': bytes.getInt32(16, Endian.little),
      'end': bytes.getInt32(20, Endian.little),
    };
    var metaValue = bytes.getInt32(24, Endian.little);
    var depth = 5;
    var maxBinNumber = ((1 << ((depth + 1) * 3)) - 1) ~/ 7;
    var maxRefLength = pow(2, (14 + depth * 3));
    var metaChar = metaValue != 0 ? String.fromCharCode(metaValue) : null;
    var skipLines = bytes.getInt32(28, Endian.little);

    // read sequence dictionary
    var nameSectionLength = bytes.getInt32(32, Endian.little);
    var refs = this._parseNameBytes(_bytes.sublist(36, 36 + nameSectionLength));

    var refNameToId = refs['refNameToId'];
    var refIdToName = refs['refIdToName'];

    // read the indexes for each reference sequence
    var currOffset = 36 + nameSectionLength;
    VirtualOffset firstDataLine;
    var indices = List.filled(refCount, (i) => i).map((e) {
      // the binning index
      var binCount = bytes.getInt32(currOffset, Endian.little);
      currOffset += 4;
      Map<int, List<Chunk>> binIndex = {};
      var stats;
      for (var j = 0; j < binCount; j += 1) {
        var bin = bytes.getUint32(currOffset, Endian.little);
        currOffset += 4;
        if (bin > maxBinNumber + 1) {
          throw Exception('tabix index contains too many bins, please use a CSI index');
        } else if (bin == maxBinNumber + 1) {
          var chunkCount = bytes.getInt32(currOffset, Endian.little);
          currOffset += 4;
          if (chunkCount == 2) {
            stats = this.parsePseudoBin(_bytes, currOffset);
          }
          currOffset += 16 * chunkCount;
        } else {
          var chunkCount = bytes.getInt32(currOffset, Endian.little);
          currOffset += 4;
          List<Chunk> chunks = List(chunkCount);
          for (var k = 0; k < chunkCount; k += 1) {
            var u = VirtualOffset.fromBytes(bytes: _bytes, offset: currOffset);
            var v = VirtualOffset.fromBytes(bytes: _bytes, offset: currOffset + 8);
            currOffset += 16;
            firstDataLine = findFirstData(firstDataLine, u);
            chunks[k] = Chunk(u, v, bin);
          }
          binIndex[bin] = chunks;
        }
      }

      // the linear index
      var linearCount = bytes.getInt32(currOffset, Endian.little);
      currOffset += 4;
      var linearIndex = List(linearCount);
      for (var k = 0; k < linearCount; k += 1) {
        linearIndex[k] = VirtualOffset.fromBytes(bytes: _bytes, offset: currOffset);
        currOffset += 8;
        firstDataLine = findFirstData(firstDataLine, linearIndex[k]);
      }
      return {'binIndex': binIndex, 'linearIndex': linearIndex, 'stats': stats};
    }).toList();

    var map = {
      'indices': indices,
      'metaChar': metaChar,
      'maxBinNumber': maxBinNumber,
      'maxRefLength': maxRefLength,
      'skipLines': skipLines,
      'firstDataLine': firstDataLine,
      'columnNumbers': columnNumbers,
      'coordinateType': coordinateType,
      'format': format,
      'refIdToName': refIdToName,
      'refNameToId': refNameToId,
      'maxBlockSize': 1 << 16,
    };
    return TbiBean(map);
  }

  Map parsePseudoBin(Uint8List bytes, num offset) {
    var lineCount = ByteData.view(bytes.buffer).getInt32(offset + 16, Endian.little);
    return {'lineCount': lineCount};
  }

  Map _parseNameBytes(List<int> namesBytes) {
    var currRefId = 0;
    var currNameStart = 0;
    var refIdToName = [];
    var refNameToId = {};
    for (var i = 0; i < namesBytes.length; i += 1) {
      if (namesBytes[i] == 0) {
        if (currNameStart < i) {
          var refName = utf8.decode(namesBytes.sublist(currNameStart, i)); //namesBytes.toString('utf8', currNameStart, i)
          refName = this.renameRefSeq(refName);
          refIdToName.add(refName);
          refNameToId[refName] = currRefId;
        }
        currNameStart = i + 1;
        currRefId += 1;
      }
    }
    return {'refNameToId': refNameToId, 'refIdToName': refIdToName};
  }
}
